iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

Day 13 我們用 FastAPI 把檢測器包裝成 Web API:POST /api/v1/conflicts/detect 提交任務,GET /api/v1/conflicts/{job_id} 查詢結果。功能都有了,但使用方式是這樣:

curl -X POST http://localhost:8000/api/v1/conflicts/detect -H "Content-Type: application/json" \
  -d '{"constraints": [{"id": "REQ-1", "text": "..."}, ...]}'
curl http://localhost:8000/api/v1/conflicts/job_20260925_194511_6da524

寫需求的人(PM、系統分析師)不會這樣用。今天要做一個網頁:貼上需求、按一個按鈕,就能看到衝突表格。

這一天在系列中的位置

第 2 週:檢測優化與生產系統

  • Day 13 → FastAPI Web API
  • Day 14 → React 前端 ← 今天
  • Day 15-16 → 用戶認證、資料庫、登入版前端
  • Day 17 → 刷新令牌與會話管理

今日目標

今天要完成:

  1. Vite + React 18 + Material-UI 專案,開發伺服器透過代理呼叫 Day 13 的 API
  2. API 客戶端 frontend/apiClient.js:統一處理請求與錯誤訊息
  3. 檢測頁 frontend/components/DetectPage.jsx:輸入需求 → 提交 → 每秒輪詢 → 顯示結果
  4. 衝突表格 frontend/components/ConflictTable.jsx:並列兩條需求原文、類型、嚴重度、置信度
  5. 實際在瀏覽器驗證:規則模式與 Mistral 7B 模式各跑一次

預期結果:在 http://localhost:3000 輸入 4 條需求,按下「開始檢測」後看到 2 個衝突。


問題背景:前端要處理哪些事

把 curl 的流程翻成使用者操作,前端要處理四件事:

curl 流程 前端要做的
手寫 JSON [{"id": "REQ-1", "text": ...}] 使用者一行一條輸入,前端自動編號
POST 拿到 job_id 顯示「任務已提交」
反覆 GET 直到 completed 輪詢:每秒查一次,完成或失敗就停
讀 JSON 裡的 conflicts 用表格呈現,並把 REQ-1 換回需求原文

為什麼要輪詢?Day 13 的 API 是任務模式:規則模式幾毫秒就完成,但 Mistral 7B 模式一份 SRS 要十幾秒。前端不能假設「提交完就有結果」。

另一個問題是跨域。前端開發伺服器跑在 localhost:3000,API 在 localhost:8000,瀏覽器會把它們視為不同來源。Day 13 已經在後端開了 CORS,但這裡改用 Vite 的代理:前端一律呼叫相對路徑 /api/v1/...,由 Vite 轉發給後端。好處是前端程式碼不用寫死後端網址,正式部署時把前端和 API 放在同一個網域即可。


實現方法

使用者輸入(每行一條)
  ↓ parseConstraints()
DetectPage ── api.submit(lines) ──→ POST /api/v1/conflicts/detect ──→ job_id
  ↓
  └─ 每秒 api.getJob(job_id) ──→ GET /api/v1/conflicts/{job_id}
       status = queued / processing → 繼續輪詢
       status = completed           → ConflictTable 顯示 results.conflicts
       status = failed              → 顯示 error
  • 輸入:使用者貼上的需求文字
  • 輸出:瀏覽器中的衝突表格
  • 檔案:frontend/ 目錄(新增)
  • 依賴:Day 13 的 src/api_main.py 必須在 localhost:8000 執行
  • 下游:Day 16 的登入版會重用 DetectPage 與 ConflictTable,只換掉 API 客戶端

專案結構:

srs-review-agent/
├── src/api_main.py              ← Day 13(後端)
└── frontend/                    ← 【新增】
    ├── package.json
    ├── vite.config.js           ← 開發伺服器與 /api/ 代理
    ├── index.html
    ├── index.jsx                ← 入口
    ├── DetectApp.jsx            ← 今天的根元件
    ├── apiClient.js             ← API 客戶端
    └── components/
        ├── DetectPage.jsx       ← 輸入、提交、輪詢
        └── ConflictTable.jsx    ← 結果表格

完成版的 repo 裡還有 frontend/App.jsx,那是 Day 16 加入登入功能後的根元件。今天用不到它。

環境準備

需要 Node.js 18 以上(Vite 5 的要求)。macOS 可以用 Homebrew 安裝:

brew install node
node -v   # 本文使用 v26.10.0

代碼示例

1. 專案設定

建立 frontend/package.json:

{
  "name": "srs-review-agent-ui",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "@mui/material": "^5.15.0",
    "@mui/icons-material": "^5.15.0",
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.2.0",
    "vite": "^5.0.0"
  }
}

沒有用 axios:瀏覽器內建的 fetch 已經夠用,少一個依賴。@emotion/* 是 Material-UI 5 的樣式引擎,必須一起安裝。

建立 frontend/vite.config.js:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
    proxy: {
      // 結尾的斜線很重要:寫成 '/api' 會連 /apiClient.js 這類前端檔案也轉給後端
      '/api/': 'http://localhost:8000'
    }
  }
})

這個斜線是我實際踩到的坑。Vite 的 proxy 用前綴比對,一開始寫成 '/api',結果瀏覽器載入 /apiClient.js 時也被轉給 FastAPI,回了 404,整個頁面一片空白。npm run build 不會經過代理,所以 build 成功並不代表開發伺服器能正常跑。

frontend/index.html 只有一個 <div id="root"> 和載入 /index.jsx 的 <script type="module">,frontend/index.jsx 則把根元件掛上去:

import React from 'react'
import ReactDOM from 'react-dom/client'
import DetectApp from './DetectApp'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <DetectApp />
  </React.StrictMode>,
)

完成版 repo 的 frontend/index.jsx 會依環境變數選擇根元件:預設是 Day 16 的 App,設定 VITE_PUBLIC_MODE=1 時才是今天的 DetectApp。

2. API 客戶端

建立 frontend/apiClient.js:

const API_BASE = '/api/v1'

async function request(path, options = {}) {
  const response = await fetch(`${API_BASE}${path}`, options)
  const data = await response.json().catch(() => ({}))
  if (!response.ok) {
    // 後端錯誤一律帶 detail 欄位(見 Day 13 的 http_exception_handler)
    throw new Error(data.detail || `HTTP ${response.status}`)
  }
  return data
}

// 每行一條需求,忽略空行
export function parseConstraints(text) {
  return text.split('\n').map((line) => line.trim()).filter(Boolean)
}

export const publicApi = {
  submit: (lines) =>
    request('/conflicts/detect', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        constraints: lines.map((text, i) => ({ id: `REQ-${i + 1}`, text })),
      }),
    }),
  getJob: (jobId) => request(`/conflicts/${jobId}`),
}

request() 把「HTTP 狀態不是 2xx」轉成例外,並優先使用後端的 detail 當錯誤訊息。Day 13 特地讓錯誤回應帶上 detail,就是為了這裡:使用者會看到「至少需要 2 個約束」,而不是籠統的「檢測失敗」。

publicApi 把 API 包成 submit / getJob 兩個函數。元件只依賴這兩個函數,不知道實際的網址,Day 16 換成登入版 API 時元件不用改。(完成版的檔案裡還有 Day 16 的 authApi()。)

3. 檢測頁:提交

建立 frontend/components/DetectPage.jsx。先看提交的部分:

export default function DetectPage({ api, onSubmitted }) {
  const [text, setText] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState('')
  const [jobId, setJobId] = useState(null)
  const [job, setJob] = useState(null)
  const [texts, setTexts] = useState({})

  const handleSubmit = async () => {
    const lines = parseConstraints(text)
    setSubmitting(true)
    setError('')
    setJob(null)
    try {
      const data = await api.submit(lines)
      // 記下 REQ 編號對應的原文,結果表格用來顯示需求內容
      setTexts(Object.fromEntries(lines.map((t, i) => [`REQ-${i + 1}`, t])))
      setJobId(data.job_id)
      onSubmitted?.(data.job_id)
    } catch (err) {
      setError(err.message)
    } finally {
      setSubmitting(false)
    }
  }

API 回傳的衝突只有 REQ-1、REQ-3 這種編號,使用者看不懂。所以提交時順便記下「編號 → 原文」的對照表 texts,交給結果表格使用。

按鈕在需求少於 2 條時停用,並顯示目前條數:

<Button fullWidth variant="contained" onClick={handleSubmit}
        disabled={submitting || lineCount < 2}>
  {submitting ? <CircularProgress size={24} /> : `🔍 開始檢測(${lineCount} 條)`}
</Button>

後端也會檢查「至少 2 條」,但前端先擋下來,使用者不用等一次來回才知道錯在哪。

4. 檢測頁:輪詢

同一個檔案中,用 useEffect 在 jobId 改變時開始輪詢:

const POLL_INTERVAL_MS = 1000

useEffect(() => {
  if (!jobId) return
  let cancelled = false
  let timer

  const poll = async () => {
    try {
      const data = await api.getJob(jobId)
      if (cancelled) return
      setJob(data)
      if (data.status !== 'completed' && data.status !== 'failed') {
        timer = setTimeout(poll, POLL_INTERVAL_MS)
      }
    } catch (err) {
      if (!cancelled) setError(err.message)
    }
  }
  poll()

  // 換任務或離開頁面時停止輪詢,避免對舊任務繼續發請求
  return () => {
    cancelled = true
    clearTimeout(timer)
  }
}, [jobId, api])

幾個設計考量:

  • 用 setTimeout 串接,而不是 setInterval:上一次請求回來才排下一次。後端很慢時,setInterval 會讓請求堆積
  • 只在非終止狀態才繼續:completed 或 failed 就停,不會永遠輪詢下去
  • 清理函數:使用者連按兩次檢測時,舊任務的輪詢會被取消;cancelled 旗標確保舊請求回來時不會覆蓋新任務的畫面
  • 依賴 api:傳入的 api 物件必須是穩定的(今天的 publicApi 是模組層級常數),否則每次 render 都會重新開始輪詢

5. 顯示結果

DetectPage 依狀態顯示不同內容:

{job?.status === 'failed' && <Alert severity="error">檢測失敗:{job.error}</Alert>}

{job?.status === 'completed' && (
  conflicts.length === 0
    ? <Alert severity="success">沒有檢測到衝突</Alert>
    : (
      <>
        <Typography sx={{ mb: 1 }}>發現 {conflicts.length} 個衝突:</Typography>
        <ConflictTable conflicts={conflicts} texts={texts} />
      </>
    )
)}

狀態標籤(queued / processing / completed / failed)與處理中的轉圈動畫,完整代碼見 frontend/components/DetectPage.jsx。

建立 frontend/components/ConflictTable.jsx,每一列並列兩條需求:

const SEVERITY_COLOR = { 高: 'error', 中: 'warning', 低: 'default' }

{conflicts.map((c) => (
  <TableRow key={`${c.req_id_1}-${c.req_id_2}-${c.description}`}>
    <TableCell>
      <div><b>{c.req_id_1}</b> {texts[c.req_id_1]}</div>
      <div><b>{c.req_id_2}</b> {texts[c.req_id_2]}</div>
    </TableCell>
    <TableCell>{c.type}</TableCell>
    <TableCell>
      <Chip label={c.severity} color={SEVERITY_COLOR[c.severity] || 'default'} size="small" />
    </TableCell>
    <TableCell>{c.description}</TableCell>
    <TableCell>{Math.round(c.confidence * 100)}%</TableCell>
    <TableCell>{c.verified ? '✅' : '—'}</TableCell>
  </TableRow>
))}

「LLM 驗證」欄對應 API 的 verified:規則模式下是 —,Mistral 模式下經過批量驗證的會顯示 ✅。Day 12 提過,verified 只代表「經過 LLM 看過」,不代表一定正確,所以表格照實呈現,不額外加上「已確認」之類的字眼。

6. 根元件

建立 frontend/DetectApp.jsx:

import React from 'react'
import { AppBar, Toolbar, Typography, Container } from '@mui/material'
import DetectPage from './components/DetectPage'
import { publicApi } from './apiClient'

export default function DetectApp() {
  return (
    <>
      <AppBar position="static">
        <Toolbar>
          <Typography variant="h6" sx={{ fontWeight: 'bold' }}>📋 SRS 審查 Agent</Typography>
        </Toolbar>
      </AppBar>
      <Container maxWidth="lg" sx={{ py: 4 }}>
        <DetectPage api={publicApi} />
      </Container>
    </>
  )
}

DetectPage 透過 api 參數取得 submit / getJob,而不是自己 import publicApi。這樣它不會綁定特定端點,Day 16 只要傳入登入版的 API 物件就能重用。


驗證結果

啟動

開兩個終端機:

# 終端 1:後端(專案根目錄)
uvicorn src.api_main:app --port 8000

# 終端 2:前端
cd frontend
npm install
npm run dev          # 完成版 repo 請用:VITE_PUBLIC_MODE=1 npm run dev
  VITE v5.4.21  ready in 70 ms

  ➜  Local:   http://localhost:3000/

也可以確認正式版能 build:

npm run build
vite v5.4.21 building for production...
✓ 914 modules transformed.
dist/index.html                  0.31 kB │ gzip:   0.25 kB
dist/assets/index-D9cBnabO.js  378.31 kB │ gzip: 117.31 kB
✓ built in 629ms

瀏覽器操作

打開 http://localhost:3000,在文字框輸入:

系統支持多用戶並行存取
系統採用單用戶模式
所有數據必須加密存儲
使用明文存儲以提高性能

按鈕從「開始檢測(0 條)」變成「開始檢測(4 條)」並可點擊。按下後,下方出現「任務狀態 completed」與:

需求 類型 嚴重度 說明 置信度 LLM 驗證
REQ-1 系統支持多用戶並行存取REQ-2 系統採用單用戶模式 邏輯矛盾 高 檢測到 多用戶 vs 單用戶 95% —
REQ-3 所有數據必須加密存儲REQ-4 使用明文存儲以提高性能 安全性衝突 高 檢測到 加密 vs 明文 95% —

我用 Playwright 驅動 headless Chromium 自動跑了這個流程,確認:

  • 只有 1 條需求時按鈕保持停用
  • 瀏覽器只發出 POST /api/v1/conflicts/detect 與 1 次 GET /api/v1/conflicts/{job_id},任務完成後沒有再輪詢
  • Console 沒有任何錯誤或警告

使用 Mistral 7B 時的輪詢

把後端換成 SRS_USE_OLLAMA=1 uvicorn src.api_main:app --port 8000,輸入 Day 13 那份 5 條需求的電商 SRS:

支持明文顯示訂單細節
不需要加密用戶的個人信息
所有支付數據必須加密傳輸
購物車數據實時同步到伺服器
支持本地離線購物車
  • 第 3 秒時狀態標籤是 processing,旁邊有轉圈動畫
  • 前端在 0.1、1.1、2.2 … 13.2 秒各查詢一次,共 14 次,任務完成後停止
  • 最後顯示 2 個衝突,「LLM 驗證」欄都是 ✅;規則誤報「明文顯示訂單細節 vs 不需要加密個人信息」已被批量驗證擋下

其中補充層回報的「所有支付數據必須加密傳輸 vs 支持本地離線購物車」,說明欄是 Mistral 產生的英文句子。這和 Day 13 看到的結果相同:補充層的判斷與輸出語言都不穩定,需要人工複核。


權衡與限制

  • 只能貼文字,不能上傳檔案:Day 3 的 src/chunking.py 能切分 Markdown SRS,但 API 目前只收約束清單。「上傳 .md → 自動抽出需求」需要 API 新增端點,列為可選延伸
  • 沒有歷史紀錄:重新整理頁面,結果就不見了,後端的任務也存在記憶體裡。Day 15-16 會把任務存進資料庫,並在登入版提供任務列表
  • 任何人都能使用:沒有登入。Day 15-16 加入認證後,才有「每個人只看得到自己的任務」
  • 輪詢 vs 推播:每秒一次的輪詢在單人使用時沒問題;使用者多時可以改用 WebSocket 或 Server-Sent Events,由後端主動推送結果

提交變更

git add frontend/package.json frontend/vite.config.js frontend/index.html frontend/index.jsx \
        frontend/DetectApp.jsx frontend/apiClient.js frontend/components/
git commit -m "Day 14: React 前端(輸入需求、輪詢任務、衝突表格)"

frontend/node_modules/ 與 frontend/dist/ 是安裝與 build 的產物,不要提交。

明天預告

現在任何人打開網頁都能送出檢測,結果也只存在記憶體裡。明天 Day 15 會加入用戶系統:註冊與登入(JWT)、密碼雜湊儲存,並把任務寫進資料庫,讓每個用戶都有自己的檢測歷史。


上一篇
Day 13:Web API 設計與實現
下一篇
Day 15:用戶認證與數據庫持久化
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言